昨天解決完呼吸過場的語言同步問題之後,我盯著那個圓圈動畫看了很久。
單純地縮放、變色,一縮一放,看久了其實有點像……呼吸球。嗯,它確實就是設計來模擬呼吸的,所以某種意義上算是成功了。
但我心裡還是覺得少了什麼。
既然都叫「賽博紓壓診所」了,使用者答題的時候,螢幕角落怎麼可以沒有一條會跳動的綠線?那種滴、滴、滴,忽然一個尖峰竄上去再滑下來的畫面,才是真正會讓人覺得「我正在被監測」的儀式感。
所以今天要做的東西就是:
打造一個即時心電圖(ECG)模擬器,而且波形要隨著測驗的進度跳動。
答題答得越快、進度推進得越急,波形就跳得越劇烈;快要答完的時候,振幅拉到最高,有一種「要被榨乾」的感覺。是裝飾,但我就是想要這個樣子。
一開始我以為畫心電圖會很難,畢竟真正的 ECG 波形有 P 波、QRS 複合波、T 波,一堆專有名詞。
但心裡又想著:我不是要做醫療儀器,我是要做一個「看起來像心電圖」的視覺特效。
心態的轉變,看待事情的角度也不同。
真正的心電圖波形,簡化到極致大概長這樣:
一條平線
↓
突然一個小凹陷(往下一點點)
↓
緊接著一個又高又窄的尖峰(往上衝一大截)
↓
再一個小凹陷(往下)
↓
一個圓滑的小丘(往上一點點)
↓
回到平線
↓
重複
我把這個波形拆成一組固定的「座標點」,每個點記錄「往右移動多少、往上或往下偏移多少」:
const beatPattern = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 12, y: -5 }, // 小凹陷
{ x: 14, y: 40 }, // 尖峰往上衝
{ x: 16, y: -20 }, // 尖峰往下甩
{ x: 18, y: 0 },
{ x: 22, y: -8 }, // 小丘
{ x: 26, y: 0 },
{ x: 40, y: 0 }
];
這一組點,就是「一次心跳」。接下來要做的事情很單純:用 Canvas 把這些點連成線,並且不斷把新的一拍接到畫面右邊,舊的從左邊推出去。
這種「畫面從右邊生出新東西、舊東西往左邊移出去消失」的效果,實作上我選了一個很有效率的做法:不是移動線條本身,而是移動時間軸的取樣起點。
offset += speed;
每一幀,把 offset 往前推進一點點,畫的時候用 offset 去決定現在要從波形的哪一段開始取樣,看起來就像整條線在往左跑。
至於「跟著測驗進度跳動」這件事,邏輯也不複雜:進度條走到哪,就拿那個百分比去控制心跳的速度跟振幅。
const speed = 2 + progress * 6; // 進度越高,線跑得越快
const amplitude = 1 + progress * 1.5; // 進度越高,波峰越誇張
題目答得越多,這個數字就越接近 1,波形也就跳得越激烈。
今天做一個獨立的示範,用一顆「下一題」按鈕模擬測驗進度推進,看心電圖怎麼跟著變化:
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>賽博紓壓診所:即時心電圖模擬器</title>
<style>
body {
font-family: 'Segoe UI', Microsoft JhengHei, sans-serif;
background: #0d1117;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
padding: 20px;
}
.card {
background: #161b22;
padding: 25px;
border-radius: 20px;
box-shadow: 0 12px 30px rgba(0,0,0,0.5);
width: 100%;
max-width: 460px;
text-align: center;
color: #e6edf3;
}
h2 { color: #4ade80; margin: 0 0 5px; }
.subtitle { color: #8b949e; font-size: 0.85rem; margin-bottom: 15px; }
.monitor {
background: #000;
border: 2px solid #238636;
border-radius: 10px;
overflow: hidden;
margin-bottom: 15px;
}
canvas { display: block; width: 100%; height: 140px; }
.stats {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
color: #4ade80;
margin-bottom: 15px;
padding: 0 4px;
}
.progress-track {
height: 10px;
background: #21262d;
border-radius: 5px;
overflow: hidden;
margin-bottom: 15px;
}
.progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4ade80, #f87171);
transition: width 0.5s ease;
}
.btn {
background-color: #238636;
color: white;
border: none;
border-radius: 50px;
padding: 12px 30px;
font-size: 1rem;
cursor: pointer;
width: 100%;
transition: 0.2s;
}
.btn:hover { background-color: #2ea043; transform: translateY(-2px); }
.ghost { background-color: #30363d; margin-top: 8px; }
.ghost:hover { background-color: #484f58; }
</style>
</head>
<body>
<div class="card">
<h2>💓 腦波監測儀</h2>
<p class="subtitle">波形會跟著測驗進度一起加劇</p>
<div class="monitor">
<canvas id="ecg" width="500" height="140"></canvas>
</div>
<div class="stats">
<span id="bpm">60 bpm</span>
<span id="progressText">進度 0%</span>
</div>
<div class="progress-track">
<div class="progress-fill" id="progressFill"></div>
</div>
<button class="btn" onclick="nextQuestion()">下一題(模擬作答)</button>
<button class="btn ghost" onclick="reset()">重新開始</button>
</div>
<script>
const canvas = document.getElementById("ecg");
const ctx = canvas.getContext("2d");
// ---------- 今天的重點:一拍的波形座標 ----------
const beatPattern = [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
{ x: 12, y: -5 },
{ x: 14, y: 40 },
{ x: 16, y: -20 },
{ x: 18, y: 0 },
{ x: 22, y: -8 },
{ x: 26, y: 0 },
{ x: 40, y: 0 }
];
const beatWidth = 40; // 一拍在時間軸上占多寬
const TOTAL_QUESTIONS = 5;
let answered = 0;
let offset = 0;
let animId = null;
// ---------- 進度決定速度與振幅 ----------
function getProgress() {
return answered / TOTAL_QUESTIONS;
}
function getSpeed() {
return 2 + getProgress() * 6;
}
function getAmplitude() {
return 1 + getProgress() * 1.5;
}
// 在波形上任意取一個 x 位置的 y 值(在兩個定義點之間用直線內插)
function sampleBeat(x) {
const localX = x % beatWidth;
for (let i = 0; i < beatPattern.length - 1; i++) {
const a = beatPattern[i];
const b = beatPattern[i + 1];
if (localX >= a.x && localX <= b.x) {
const t = (localX - a.x) / (b.x - a.x || 1);
return a.y + (b.y - a.y) * t;
}
}
return 0;
}
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 背景格線,營造醫療儀器感
ctx.strokeStyle = "rgba(74, 222, 128, 0.08)";
for (let gx = 0; gx < canvas.width; gx += 20) {
ctx.beginPath();
ctx.moveTo(gx, 0);
ctx.lineTo(gx, canvas.height);
ctx.stroke();
}
const midY = canvas.height / 2;
const amplitude = getAmplitude();
ctx.beginPath();
ctx.strokeStyle = "#4ade80";
ctx.lineWidth = 2;
ctx.shadowColor = "#4ade80";
ctx.shadowBlur = 6;
for (let x = 0; x < canvas.width; x++) {
const sampleX = x + offset;
const y = midY - sampleBeat(sampleX) * amplitude;
if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.shadowBlur = 0;
offset += getSpeed();
animId = requestAnimationFrame(draw);
}
// ---------- 模擬測驗進度推進 ----------
function nextQuestion() {
if (answered >= TOTAL_QUESTIONS) return;
answered++;
const progress = getProgress();
document.getElementById("progressFill").style.width = (progress * 100) + "%";
document.getElementById("progressText").innerText = `進度 ${Math.round(progress * 100)}%`;
document.getElementById("bpm").innerText = `${Math.round(60 + progress * 60)} bpm`;
}
function reset() {
answered = 0;
document.getElementById("progressFill").style.width = "0%";
document.getElementById("progressText").innerText = "進度 0%";
document.getElementById("bpm").innerText = "60 bpm";
}
draw();
</script>
</body>
</html>
再存成 .html 打開,先看一下靜止狀態的心跳線,再連續按幾次「下一題」,你會明顯看到線跑得越來越快,波峰也越跳越高,跟真的壓力緊繃起來的感覺還挺像的。
1. 為什麼用「移動取樣起點」而不是真的移動線條上的每個點?
一開始我想的是,每一幀把陣列裡所有點的 x 座標都減一,模擬線往左移動。寫完才發現,陣列會一直增長(新的點要加進來),舊的點要定期砍掉,還要處理陣列邊界,邏輯複雜許多。
後來想通:我不需要真的有一堆點在動,我只需要在每一幀,問「現在這個 x 座標,對應到波形的哪個位置」,然後把答案畫出來就好。
波形本身是固定不變的 beatPattern,變的只是 offset 這個數字,用它去決定「現在看到的是波形的哪一段」。畫面上的每一個像素都是即時算出來的,不需要儲存任何歷史資料邏輯乾淨。
2. sampleBeat() 裡面那段內插在幹嘛?
beatPattern 裡只定義了幾個關鍵點,點跟點之間其實是空的。如果只是「找到離 x 最近的點」,畫出來的線會有明顯的鋸齒感,不夠平滑。
內插做的事情是:如果 x 落在兩個定義點中間,就按照比例去抓一個介於兩者之間的 y 值。這樣即使只定義了 9 個點,畫出來也會是一條連續的折線,不會卡卡的。
3. localX = x % beatWidth 這行是什麼意思?
波形不是只跳一次,是要重複很多次的。beatWidth 是「一拍佔多寬」,用 %(取餘數)把任意大的 x 座標,折回「這一拍內部的相對位置」。這樣不管現在跑到第幾拍,都能用同一組 beatPattern 重複套用,不用為每一拍都準備一份資料。
4. 為什麼振幅跟速度的計算要寫成 2 + progress * 6,不是直接 progress * 8?
如果直接用 progress * 8,那在測驗剛開始、progress 還是 0 的時候,速度會變成 0,畫面上的線就完全靜止了,看起來像當機,而不是「平靜的心跳」。
加上一個基礎值(像是 2),代表「就算還沒開始答題,心跳也是會動的」,只是比較平緩而已,之後才隨著進度逐漸加快。這個小細節讓整個效果在測驗一開始就有生命力,不會一開場就是個死畫面。
今天讓我體會到一件事:做一個「看起來專業」的視覺效果,不代表邏輯要跟著一樣複雜。
一開始我滿擔心心電圖這種東西會牽扯到很多生理訊號的知識,結果做完才發現,核心完全是純數學:一組固定的座標點、一個不斷往前推進的位移量、再加一點點內插計算而已。難度沒有想像中高,反而是「怎麼讓它看起來夠真實」這件事花了比較多時間,像是要不要加格線背景、線條要不要加發光效果,這些調整比演算法本身還花心思。
另外,今天「用進度去驅動視覺變化」這個做法,也讓我想起之前做六維度歸一化的時候,核心都是同一招:找到一個 0 到 1 之間的比例,拿這個比例去控制某個看得見的東西。只是那時候控制的是雷達圖上的長度,今天控制的是線條跑動的速度跟振幅。
今天我們用一組固定的波形座標,加上不斷推進的採樣量,產生了一個會隨監測跳動的即時心電圖監測儀,讓回答問題這件事終於有了「正在被」的賽博感。
寫完這篇之後我往回滑瀏覽這幾天的文章,才意識到一件事:從模擬合成開始,到今天的心電圖,這個階段其實已經悄悄走了過去。
點擊音、破裂音、求簽音、四種語言切換、呼吸過場的語言同步、現在又多了一個會跳動的心電圖機——這些東西單獨看都只是一個小功能,但串起來到底看,整間練習已經從“安靜的表格”變成了一個會發出聲音、會說話、會呼吸、連心跳都看得見的東西。
所以,第22天,我想停下來整理:回顧第二階段,為什麼每個小細節都值得花這麼長時間去摳。
先喘口氣,到底看看這條路是怎麼走過來的!